-
Notifications
You must be signed in to change notification settings - Fork 2
refactor: 이력서 API Tanstack-Query 마이그레이션 #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Walkthrough이 PR은 이력서 도메인에 타입·API 클라이언트·쿼리 키·React Query 쿼리·뮤테이션을 추가하고, 여러 컴포넌트를 무한 스크롤·뮤테이션 기반으로 전환합니다. 좋아요/북마크/업로드 로직이 React Query 뮤테이션과 훅으로 통합되었습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor 사용자 as User
participant ListComp as Resume List 컴포넌트
participant Q as useInfiniteQuery (React Query)
participant API as src/api/resume/apis.ts
User->>ListComp: 스크롤/필터 조작
ListComp->>Q: fetch page (resumeKeys.list, pageParam)
Q->>API: GET /api/resumes?... (cursorId, limit, params)
API-->>Q: { data, hasNext, nextCursor }
Q-->>ListComp: pages 누적 반환
ListComp->>User: 렌더(data.pages.flatMap(...))
alt 센티넬 inView && hasNextPage
ListComp->>Q: fetchNextPage()
Q->>API: GET 다음 페이지
API-->>Q: 다음 페이지 응답
Q-->>ListComp: pages 업데이트
end
sequenceDiagram
autonumber
actor 사용자 as User
participant Item as ResumeFolder (아이템)
participant Hook as useResumeLikeBookmark
participant M as useMutation (postLike/postBookmark)
participant API as POST /api/(likes|bookmarks)
participant QC as QueryClient
User->>Item: 좋아요/북마크 클릭
Item->>Hook: clickLike/clickBookmark 호출
Hook->>Item: 낙관적 상태 변경(토글/카운트)
Hook->>M: mutateAsync(payload)
M->>API: POST (credentials)
alt 성공
API-->>M: 200 OK
M->>QC: invalidateQueries(resumeKeys.list/detail/best)
else 실패
API-->>M: Error
M-->>Hook: reject
Hook->>Item: 롤백(토글/카운트 복구)
end
sequenceDiagram
autonumber
actor 사용자 as User
participant Add as AddResume 컴포넌트
participant M as useResumeUploadMutation
participant API as POST /api/resumes
participant QC as QueryClient
User->>Add: 파일 + 메타 제출
Add->>M: mutateAsync({ file, data })
M->>API: FormData(파일 + JSON)
alt 성공
API-->>M: 200 OK + JSON
M->>QC: invalidateQueries(resumeKeys.all)
Add-->>User: 모달 닫기 + refetch
else 실패
API-->>M: Error
M-->>Add: reject(Error)
Add-->>User: 에러 표시
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes
Possibly related PRs
Suggested reviewers
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (11)
src/components/mypage/ResumeFolder.tsx (3)
57-79: 낙관적 업데이트 롤백이 올바르게 동작하지 않습니다 (상태 스냅샷 없이 이전값 복구 불가).
catch에서setIsLike(!isLike)/setIsBookmark(!isBookmark)를 호출하면, 클로저에 캡처된 이전 값 때문에 실제로는 “되돌리기”가 아닌 “다시 토글”이 됩니다. 또한 카운트 복구도 동일한 문제를 겪습니다. 클릭 직전의 스냅샷을 잡아서 실패 시 그대로 복원하세요.추천 패치:
const clickLike = async (event: React.MouseEvent) => { event.preventDefault() + if (likeMutation.isPending) return try { const newIsLike = !isLike const newLikeCount = newIsLike ? likeCount + 1 : likeCount - 1 + // snapshot for rollback + const prevIsLike = isLike + const prevLikeCount = likeCount // 낙관적 업데이트 setIsLike(newIsLike) setLikeCount(newLikeCount) await likeMutation.mutateAsync({ contentId: Number(resume.id), category: 'RESUME', likeStatus: newIsLike, }) onLikeUpdate(resume.id, newLikeCount) } catch (err) { - setIsLike(!isLike) - setLikeCount(isLike ? likeCount : likeCount - 1) + setIsLike(prevIsLike) + setLikeCount(prevLikeCount) console.error(err) } } @@ const clickBookmark = async (event: React.MouseEvent) => { event.preventDefault() + if (bookmarkMutation.isPending) return try { const newIsBookmark = !isBookmark const newBookmarkCount = newIsBookmark ? bookmarkCount + 1 : bookmarkCount - 1 + // snapshot for rollback + const prevIsBookmark = isBookmark + const prevBookmarkCount = bookmarkCount // 낙관적 업데이트 setIsBookmark(newIsBookmark) setBookmarkCount(newBookmarkCount) await bookmarkMutation.mutateAsync({ contentId: Number(resume.id), category: 'RESUME', bookmarkStatus: newIsBookmark, }) onBookmarkUpdate(resume.id, newBookmarkCount) } catch (err) { - setIsBookmark(!isBookmark) - setBookmarkCount(isBookmark ? bookmarkCount : bookmarkCount - 1) + setIsBookmark(prevIsBookmark) + setBookmarkCount(prevBookmarkCount) console.error(err) } }Also applies to: 81-105
15-22: Props 타입이 실제 사용과 불일치합니다(배열 요소에 id 접근).
likeList,bookmarkList를string[]로 선언했지만 코드에서는 객체의id를 사용합니다. 타입을 맞춰 런타임 오류와 불필요한any회피하세요.interface ResumeProps { likeCount: number resume: Resume - likeList: string[] // 좋아요 리스트 + likeList: Array<{ id: number | string }> onLikeUpdate: (resumeId: string, newLikeCount: number) => void - bookmarkList: string[] // 북마크 리스트 + bookmarkList: Array<{ id: number | string }> onBookmarkUpdate: (resumeId: string, newBookmarkCount: number) => void }
156-166: 아이콘 대체 텍스트 오타(북마크인데 like로 표기).접근성 및 의미 정확도를 위해 alt 텍스트를 교정하세요.
- alt="like-on" + alt="bookmark-on" @@ - alt="like-off" + alt="bookmark-off"src/components/mypage/Resume.tsx (1)
137-148: Link 중첩으로 인한 잘못된 마크업/네비게이션 충돌.
ResumeFolder내부에 이미Link가 있는데, 여기서 또 감싸 중첩<a>가 됩니다. Next.js에서 경고/오류 및 예기치 않은 네비게이션이 발생할 수 있습니다. 외부Link를 제거하세요.- {resumes.map((resume) => ( - <Link key={resume.id} href={`/resume/${resume.id}`}> - <ResumeFolder - likeCount={resume.likeCount} - resume={resume} - likeList={likeList} - onLikeUpdate={handleLikeUpdate} - bookmarkList={bookmarkList} - onBookmarkUpdate={handleBookmarkUpdate} - /> - </Link> - ))} + {resumes.map((resume) => ( + <ResumeFolder + key={resume.id} + likeCount={resume.likeCount} + resume={resume} + likeList={likeList} + onLikeUpdate={handleLikeUpdate} + bookmarkList={bookmarkList} + onBookmarkUpdate={handleBookmarkUpdate} + /> + ))}src/components/resume/ResumeFolder.tsx (4)
43-45: bookmarkCount 초기값이 likeCount로 설정되어 잘못된 상태 유도북마크 카운트가 좋아요 카운트에 종속됩니다. 별도 초기값을 받아 사용하거나(resume.bookmarkCount) 안전한 기본값을 두세요.
다음 중 하나로 수정 권장:
옵션 A) 부모로부터 명시적 전달
interface ResumeProps { likeCount: number + bookmarkCount: number resume: Resume @@ export default function ResumeFolder({ likeCount: initialLikeCount, + bookmarkCount: initialBookmarkCount, resume, @@ - const [bookmarkCount, setBookmarkCount] = useState(initialLikeCount) + const [bookmarkCount, setBookmarkCount] = useState(initialBookmarkCount)옵션 B) resume 필드 사용(존재 시)
-const [bookmarkCount, setBookmarkCount] = useState(initialLikeCount) +const [bookmarkCount, setBookmarkCount] = useState(resume.bookmarkCount ?? 0)
57-74: 좋아요 낙관적 업데이트 롤백 로직이 반대로 동작함(실제 롤백 불가)에러 시
setIsLike(!isLike)는 낙관적 업데이트 값과 동일해 롤백이 되지 않습니다. 이전 스냅샷을 캡처해 적용하세요. 음수 방지도 필요합니다.const clickLike = async (event: React.MouseEvent) => { event.preventDefault() - try { - const newIsLike = !isLike - const newLikeCount = newIsLike ? likeCount + 1 : likeCount - 1 - // 낙관적 업데이트 - setIsLike(newIsLike) - setLikeCount(newLikeCount) - await likeMutation.mutateAsync({ + if (likeMutation.isPending) return + const prevIsLike = isLike + const prevLikeCount = likeCount + const nextIsLike = !prevIsLike + const nextLikeCount = Math.max(0, prevLikeCount + (nextIsLike ? 1 : -1)) + try { + // 낙관적 업데이트 + setIsLike(nextIsLike) + setLikeCount(nextLikeCount) + await likeMutation.mutateAsync({ contentId: Number(resume.id), category: 'RESUME', - likeStatus: newIsLike, + likeStatus: nextIsLike, }) - onLikeUpdate(resume.id, newLikeCount) + onLikeUpdate(resume.id, nextLikeCount) } catch (err) { - // 에러 시 롤백 - setIsLike(!isLike) - setLikeCount(isLike ? likeCount : likeCount - 1) + // 에러 시 롤백 + setIsLike(prevIsLike) + setLikeCount(prevLikeCount) console.error(err) } }
82-101: 북마크 낙관적 업데이트 롤백도 동일 문제 및 음수 방지 누락이전 상태를 저장해 실패 시 정확히 복구하세요. 중복 요청 방지도 포함.
const clickBookmark = async (event: React.MouseEvent) => { event.preventDefault() - try { - const newIsBookmark = !isBookmark - const newBookmarkCount = newIsBookmark - ? bookmarkCount + 1 - : bookmarkCount - 1 - // 낙관적 업데이트 - setIsBookmark(newIsBookmark) - setBookmarkCount(newBookmarkCount) - await bookmarkMutation.mutateAsync({ + if (bookmarkMutation.isPending) return + const prevIsBookmark = isBookmark + const prevBookmarkCount = bookmarkCount + const nextIsBookmark = !prevIsBookmark + const nextBookmarkCount = Math.max( + 0, + prevBookmarkCount + (nextIsBookmark ? 1 : -1), + ) + try { + // 낙관적 업데이트 + setIsBookmark(nextIsBookmark) + setBookmarkCount(nextBookmarkCount) + await bookmarkMutation.mutateAsync({ contentId: Number(resume.id), category: 'RESUME', - bookmarkStatus: newIsBookmark, + bookmarkStatus: nextIsBookmark, }) - onBookmarkUpdate(resume.id, newBookmarkCount) + onBookmarkUpdate(resume.id, nextBookmarkCount) } catch (err) { - // 에러 시 롤백 - setIsBookmark(!isBookmark) - setBookmarkCount(isBookmark ? bookmarkCount : bookmarkCount - 1) + // 에러 시 롤백 + setIsBookmark(prevIsBookmark) + setBookmarkCount(prevBookmarkCount) console.error(err) } }
46-55: ResumeFolder.tsx: likeList/bookmarkList 타입 정의 및 useEffect any 제거 필요
- ResumeProps의 likeList/bookmarkList를 string[]→Array<{id: string | number}>로 변경
- useEffect 내부 필터 비교를
String(item.id) === String(resume.id)형태로 안전하게 수정- 전체 도메인에서 resume.id의 타입(number vs string) 단일 타입으로 통일 필요
src/app/(protected)/resume/[resumeId]/page.tsx (2)
43-55: Google Drive ID 파싱 로직 보강현재
includes('/d/')와split('/d/')에만 의존합니다.open?id=...형식 등 다양한 드라이브 URL을 처리하도록 보강하세요. 예외 상황에서도 안전하게 동작해야 합니다.- if (!resume.url || !resume.url.includes('/d/')) { - // url이 없거나 Drive 링크 아님 - return ( - <div className="w-[55.625rem] text-center text-gray"> - 등록된 이력서가 없습니다. - </div> - ) - } - - // 정상 데이터 - const driveId = resume.url.split('/d/')[1].split('/')[0] + // Drive URL 파싱 ( /file/d/:id, ?id=:id 모두 지원 ) + const extractDriveId = (url: string) => { + try { + const u = new URL(url) + const fromPath = u.pathname.match(/\/d\/([^/]+)/)?.[1] + const fromQuery = u.searchParams.get('id') + return fromPath || fromQuery + } catch { + return null + } + } + const driveId = resume.url ? extractDriveId(resume.url) : null + if (!driveId) { + return ( + <div className="w-[55.625rem] text-center text-gray"> + 등록된 이력서가 없습니다. + </div> + ) + } const pdfPreviewUrl = `https://drive.google.com/file/d/${driveId}/preview` const pdfDownloadUrl = `https://drive.google.com/uc?export=download&id=${driveId}`
71-90: a 안에 button 중첩 제거 (비접근성·비표준 구조)인터랙티브 요소 중첩을 피하고, 링크만 사용해도 충분합니다. 접근성 라벨과 대체 텍스트도 보강하세요.
- {/* 다운로드 버튼 */} - <div className="flex flex-col gap-5"> - <button - className="flex justify-center items-center w-[2.5rem] h-[2.5rem] shadow-md border border-gray outline-none rounded-full" - type="button" - > - <a - href={pdfDownloadUrl} - download="name.pdf" - className="flex justify-center items-center w-[2rem] h-[2rem] outline-none" - > - <Image - src="/pdfdown.png" - width={20} - height={20} - alt="DownIMG" - /> - </a> - </button> - </div> + {/* 다운로드 버튼 */} + <div className="flex flex-col gap-5"> + <a + href={pdfDownloadUrl} + download="name.pdf" + aria-label="PDF 다운로드" + className="flex justify-center items-center w-[2.5rem] h-[2.5rem] shadow-md border border-gray outline-none rounded-full" + > + <Image + src="/pdfdown.png" + width={20} + height={20} + alt="PDF 다운로드" + /> + </a> + </div>src/app/(protected)/resume/@resumeList.tsx (1)
11-26: 내부 Resume 타입과 실제 데이터 매핑 불일치(id/year 타입)
- Resume.id는 string인데 convertToResume에서 number일 가능성이 높습니다. year도 서버에서 number/없음일 수 있습니다. 타입 불일치로 TS 오류/런타임 혼선이 납니다. 컨버터에서 명시적 변환을 권장합니다.
🧹 Nitpick comments (34)
src/components/mypage/ResumeFolder.tsx (3)
154-172: 중복 클릭/중복 요청 방지 및 접근성 개선(버튼 비활성화, aria-disabled).뮤테이션 진행 중 추가 클릭을 무시하고, 버튼을 비활성화해 중복 트래픽과 상태 경합을 방지하세요.
- <button type="button" onClick={clickBookmark}> + <button + type="button" + onClick={clickBookmark} + disabled={bookmarkMutation.isPending} + aria-disabled={bookmarkMutation.isPending} + > @@ - <button type="button" onClick={clickLike}> + <button + type="button" + onClick={clickLike} + disabled={likeMutation.isPending} + aria-disabled={likeMutation.isPending} + >
8-9: 미사용 코드/의존성 제거.
useLike/useBookmark와postLike/postBookmark는 더 이상 사용되지 않습니다. 불필요한 의존성을 줄이세요.-import { useLike } from '@/app/blog/_lib/useLike' -import { useBookmark } from '@/app/blog/_lib/useBookmark' @@ - const { postLike } = useLike() - const { postBookmark } = useBookmark()Also applies to: 32-33
67-74: TanStack Query의 onMutate/onError를 활용한 정석 낙관적 업데이트 고려.현재 컴포넌트 내에서 낙관·롤백을 처리하고 있는데, 뮤테이션 훅에서
onMutate(스냅샷 반환)와onError(스냅샷 복구)를 사용하면 관심사가 정리되고 중복 코드가 줄어듭니다. 필요 시 훅 리팩토링을 제안합니다.원하시면
useResumeLikeMutation/useResumeBookmarkMutation에 낙관/롤백을 내장하는 패치도 제공하겠습니다.Also applies to: 93-99
src/components/mypage/Resume.tsx (3)
59-71: setTimeout 기반 재조회는 불안정합니다(경합/지연 가능).뮤테이션 훅에서 이미
invalidateQueries(resumeKeys.all)을 수행하므로, 여기서는 타이머 없이refetch()또는 별도 키 무효화로 일관되게 처리하는 편이 안전합니다. 타이머는 제거를 권장합니다.- const handleLikeUpdate = (resumeId: string, newLikeCount: number) => { - setTimeout(() => { - checkLike() - refetch() - }, 500) - } + const handleLikeUpdate = () => { + checkLike() + refetch() + } @@ - const handleBookmarkUpdate = (resumeId: string, newBookmarkCount: number) => { - setTimeout(() => { - checkBookmark() - refetch() - }, 500) - } + const handleBookmarkUpdate = () => { + checkBookmark() + refetch() + }
90-112: 리스트 아이템의 사용자 id는 서버 값을 우선 사용하세요.현재
user.id를userId(현재 사용자)로 채우고 있는데, 서버item.user.id가 존재한다면 그 값을 우선 사용하는 편이 일반적입니다. 공동/관리자 조회 등에서도 안전합니다.- user: { - id: userId, + user: { + id: item.user?.id ?? userId, name: item.user.name, profileImage: item.user.profileImage, year: item.user.year, mainPosition: item.position, },
36-47: useCallback 의존성 생략 확인 필요.
fetchLikes/fetchBookmarks를 의존성에서 제외했습니다. 현 구현은 안정적일 수 있으나, 훅 구현 변경 시 스테일 클로저가 될 수 있습니다. ESLint 규칙(react-hooks/exhaustive-deps) 무시 여부/사유를 주석으로 명시하거나, 의존성을 포함해도 참조 동일성을 보장하도록 훅을 안정화하는 방안을 검토해주세요.원하시면
useLike/useBookmark반환 함수를useCallback으로 감싼 안정 버전으로 리팩토링 패치를 제안드릴 수 있습니다.Also applies to: 48-57
src/api/resume/types.ts (2)
12-27: 상세 타입에 bookmarkCount(옵셔널) 추가 고려.UI에서 북마크 카운트를 다룰 여지가 있으므로, 서버에서 제공한다면 타입에 포함시키는 편이 좋습니다.
export interface ResumeDetail { id: number createdAt: number title: string url: string category: string position: string likeCount: number + bookmarkCount?: number user: { id: number name: string profileImage: string year: number mainPosition: string } }
2-9: 정렬/필터 파라미터를 리터럴 유니온으로 제한해 계약 명확화.
sortBy/category가 서버에서 정해진 값이라면 리터럴 유니온으로 좁히는 것을 권장합니다.export interface ResumeQueryParams { position?: string[] year?: string[] - category?: string + category?: 'ALL' | 'INTERVIEW' | 'CS' | 'FE' | 'BE' cursorId?: number limit?: number - sortBy?: string + sortBy?: 'LATEST' | 'POPULAR' }src/components/mypage/AddResume.tsx (2)
70-95: 로딩 상태는 mutation의 isPending을 사용하고, 수동 isLoading 제거 권장useMutation이 isPending을 제공하므로 수동 setIsLoading는 중복입니다. 또한 onSuccess에서 invalidateQueries가 이미 수행되어 fetchData() 호출은 중복 가능성이 높습니다(필요 시 유지해도 되나 네트워크 중복 고려).
아래처럼 최소 변경으로 수동 로딩 제거를 제안합니다.
try { - setIsLoading(true) setAddError('') @@ - console.log('이력서 생성 성공') - fetchData() + console.log('이력서 생성 성공') + // 필요 시 fetchData() 유지, 단 캐시 무효화로 대부분 갱신됨 setModal(false) } catch (err: any) { console.error('이력서 생성 실패:', err) - setAddError(err.message || '네트워크 오류가 발생했습니다.') - } finally { - setIsLoading(false) - } + setAddError( + err?.response?.data?.message ?? + err?.message ?? + '네트워크 오류가 발생했습니다.' + ) + }추가로 버튼 비활성화/스피너는
uploadMutation.isPending으로 대체를 권장합니다(별도 수정).
10-10: 요청 페이로드 타입 명시로 안전성 강화requestData를 서버 계약 타입으로 명시해 회귀를 줄입시다.
import { useResumeUploadMutation } from '@/api/resume/mutations' +import type { ResumeUploadRequest } from '@/api/resume/types'- const requestData = { + const requestData: ResumeUploadRequest = { category: formData.resumeCategory, position: formData.resumePosition, title: formData.resumeTitle, isMain: formData.resumeIsMain, }Also applies to: 74-80
src/components/resume/OtherResume.tsx (2)
31-33: 무한 쿼리 데이터는 모든 페이지 평탄화 권장첫 페이지만 사용하는 대신 전체 pages를 합쳐 목록을 구성하면 스크롤 확장 시 자연스럽습니다.
- // 모든 페이지의 데이터를 평탄화하여 첫 번째 페이지만 사용 - const otherData = data?.pages[0]?.data ?? [] + // 모든 페이지 데이터 평탄화 + const otherData = + data?.pages.flatMap((page) => page.data ?? []) ?? []
42-47: 에러와 빈 상태를 분리 렌더링현재는 에러/빈 상태가 동일 메시지로 처리됩니다. 구분하면 UX가 선명해집니다.
- if (isError || otherData.length === 0) { + if (isError) { return ( <div className="flex flex-col w-[14.5rem] h-auto rounded-xl shadow-md mt-1"> - <div className="p-4 text-center text-gray">이력서가 없습니다.</div> + <div className="p-4 text-center text-gray">목록을 불러오지 못했습니다.</div> </div> ) } + if (otherData.length === 0) { + return ( + <div className="flex flex-col w-[14.5rem] h-auto rounded-xl shadow-md mt-1"> + <div className="p-4 text-center text-gray">이력서가 없습니다.</div> + </div> + ) + }src/components/resume/ResumeFolder.tsx (4)
35-37: 중복 클릭 방지 및 공통 낙관적 업데이트는 훅(onMutate)으로 이전 권장
- 버튼 다중 클릭 시 중복 요청/카운트 드리프트 위험이 있습니다.
likeMutation.isPending/bookmarkMutation.isPending동안 클릭을 막아주세요.- 낙관적 업데이트/롤백은 각 컴포넌트마다 구현하지 말고,
useResumeLikeMutation/useResumeBookmarkMutation의onMutate/onError로 공통 처리하면 중복이 줄고 일관성이 올라갑니다.예시(뮤테이션 훅 내부):
return useMutation({ mutationFn: (data: LikeBookmarkRequest) => postLike(data), + onMutate: async (vars) => { + await queryClient.cancelQueries({ queryKey: resumeKeys.all }) + const prev = queryClient.getQueriesData({ queryKey: resumeKeys.all }) + // TODO: prev를 바탕으로 낙관적 캐시 업데이트 + return { prev } + }, + onError: (_err, _vars, ctx) => { + // TODO: ctx.prev로 롤백 + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: resumeKeys.all }) }, })
153-169: 버튼 비활성화, aria-pressed, alt 텍스트 수정
- 진행 중 비활성화 및 접근성 속성 추가.
- 북마크 아이콘 alt가
like-on/off로 표기되어 있습니다.-<button type="button" onClick={clickBookmark}> +<button + type="button" + onClick={clickBookmark} + disabled={bookmarkMutation.isPending} + aria-pressed={isBookmark} +> @@ - alt="like-on" + alt="bookmark-on" @@ - alt="like-off" + alt="bookmark-off" @@ -<button type="button" onClick={clickLike}> +<button + type="button" + onClick={clickLike} + disabled={likeMutation.isPending} + aria-pressed={isLike} +>Also applies to: 171-189
38-38: 미사용 로컬 상태 제거
resumes상태는 사용되지 않습니다. 삭제하여 불필요한 리렌더 가능성을 줄이세요.-const [resumes, setResumes] = useState<Resume[]>([])
8-9: 레거시 훅 및 메서드 정리
useLike/useBookmark및postLike/postBookmark는 더 이상 사용되지 않습니다. 정리하세요.-import { useLike } from '@/app/blog/_lib/useLike' -import { useBookmark } from '@/app/blog/_lib/useBookmark' @@ -const { postLike } = useLike() -const { postBookmark } = useBookmark()Also applies to: 32-33
src/components/resume/BestResume.tsx (1)
32-33: 드롭다운이 닫힌 동안 불필요한 네트워크 요청 방지(옵션 enabled 지원 제안)드롭다운이 닫힌 상태에도 최초 페이지가 즉시 fetch 됩니다. 훅에
enabled옵션을 받아isOpen일 때만 동작하도록 하면 UX/트래픽이 개선됩니다.예시(훅 및 사용부 동시 변경):
-// src/api/resume/queries.ts -export const useBestResumeListQuery = ( - setAuthModalOpen: (open: boolean) => void, - limit: number = 12, -) => { +export const useBestResumeListQuery = ( + setAuthModalOpen: (open: boolean) => void, + limit: number = 12, + options?: { enabled?: boolean }, +) => { return useInfiniteQuery({ queryKey: resumeKeys.bestList(), queryFn: ({ pageParam }) => fetchBestResumes(pageParam, limit, setAuthModalOpen), initialPageParam: undefined as number | undefined, getNextPageParam: (lastPage) => lastPage.hasNext ? lastPage.nextCursor : undefined, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, + enabled: options?.enabled ?? true, }) }-const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = - useBestResumeListQuery(setAuthModalOpen, limit) +const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useBestResumeListQuery(setAuthModalOpen, limit, { enabled: isOpen })src/api/resume/keys.ts (2)
9-14: 객체 파라미터를 키로 직접 사용 시 정규화 필요(정렬/불변화)
ResumeQueryParams의 배열 필드(position/year)가 순서에 따라 서로 다른 키로 해석될 수 있습니다. 키 폭주/캐시 미스 방지를 위해 정규화하세요.-export const resumeKeys = { +const normalizeParams = (params: ResumeQueryParams) => { + return { + ...params, + position: params.position ? [...params.position].sort() : undefined, + year: params.year ? [...params.year].sort() : undefined, + } +} + +export const resumeKeys = { // 이력서 목록 키 lists: () => [...resumeKeys.all, 'list'] as const, - list: (params: ResumeQueryParams) => [...resumeKeys.lists(), params] as const, + list: (params: ResumeQueryParams) => + [...resumeKeys.lists(), normalizeParams(params)] as const, // 페이지별 쿼리 키 page: (params: ResumeQueryParams, pageNumber: number) => - [...resumeKeys.list(params), 'page', pageNumber] as const, + [...resumeKeys.list(params), 'page', pageNumber] as const,
18-23: 키 스코프 세분화 고려(선택)뮤테이션에서
resumeKeys.all무효화는 광범위합니다. 영향 범위가 좁다면detail(id)혹은 특정 리스트 키만 무효화하도록 조정하면 불필요한 refetch를 줄일 수 있습니다.src/api/resume/mutations.ts (2)
36-43: 업로드 성공 시 무효화 범위 축소 및 타입 제네릭 지정업로드 후에도 전체 무효화 대신 목록/베스트 중심으로 갱신하면 충분합니다. 반환 타입이
any인 점은 추후 명세가 정해지면 제네릭으로 명시해 주세요.- return useMutation({ - mutationFn: ({ file, data }: { file: File; data: ResumeUploadRequest }) => - uploadResume(file, data), - onSuccess: () => { - // 이력서 업로드 성공 시 관련 쿼리 무효화 - queryClient.invalidateQueries({ queryKey: resumeKeys.all }) - }, - }) + return useMutation<unknown, Error, { file: File; data: ResumeUploadRequest }>({ + mutationKey: [...resumeKeys.all, 'upload'], + mutationFn: ({ file, data }) => uploadResume(file, data), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: resumeKeys.lists() }), + queryClient.invalidateQueries({ queryKey: resumeKeys.bestList() }), + ]) + }, + })
6-17: 중복 패턴을 훅 팩토리로 축소 가능like/bookmark 훅은 동일한 구조입니다.
createToggleMutation(postFn, keySegment)형태의 팩토리로 중복 제거하면 유지보수가 쉬워집니다.Also applies to: 19-30
src/app/(protected)/resume/[resumeId]/page.tsx (2)
34-41: 오류 메시지 노출 개선
isError일 때 서버 메시지를 사용자에게 전달하는 편이 디버깅/UX에 유리합니다.error를 활용해 구체 메시지(가능하면 i18n)로 교체를 고려하세요.
121-123: ProfileBox에 불필요한 error 전달현 구현의 ProfileBox는
loading || error면 스켈레톤을 보여 실제 에러 문구가 숨겨집니다. 이 페이지에서는 로딩 분기가 별도로 있으므로error는 전달하지 않는 편이 깔끔합니다.- error={isError ? 'Error' : ''} + error=""src/api/resume/queries.ts (1)
23-42: 반환 타입 제네릭 명시로 타입 안전성 강화
use(Infinite)Query에 서버 응답 타입(예:ResumeListResponse,ResumeDetail,BestResumeResponse,UserResumeResponse)을 제네릭으로 지정하면 후속 코드에서 안전합니다.apis.ts반환 타입과 맞춰 적용해 주세요.Also applies to: 45-54, 61-70, 75-84
src/api/resume/apis.ts (8)
52-57: GET 요청에 불필요한 Content-Type 헤더 제거
- GET에는 본문이 없으므로 Content-Type 지정이 불필요합니다. 일부 서버/프록시에서 의미 없는 헤더가 문제를 유발할 수 있습니다. 제거를 권장합니다.
- const response = await fetch(`${RESUME_API_BASE}?${params.toString()}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) + const response = await fetch(`${RESUME_API_BASE}?${params.toString()}`, { + method: 'GET', + })
77-82: 상세 조회도 동일하게 헤더 정리
- 위와 동일 사유로 GET의 Content-Type 제거를 권장합니다.
- const response = await fetch(`${RESUME_API_BASE}/${resumeId}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) + const response = await fetch(`${RESUME_API_BASE}/${resumeId}`, { + method: 'GET', + })
101-105: API 레이어에서 UI 사이드이펙트(setAuthModalOpen) 제거 권고
- API 레이어가 모달 오픈을 직접 호출하면 관심사가 섞입니다. 401은 도메인 예외(AuthError)를 던지고, 호출부(쿼리 훅/컴포넌트)에서 UI를 제어하도록 분리하세요.
적용 예시(요지):
-export const fetchBestResumes = async ( - cursorId?: number, - limit: number = 12, - setAuthModalOpen?: (open: boolean) => void, -): Promise<BestResumeResponse> => { +export const fetchBestResumes = async ( + cursorId?: number, + limit: number = 12, +): Promise<BestResumeResponse> => { @@ - if (response.status === 401) { - setAuthModalOpen?.(true) - throw new Error('로그인이 필요합니다.') - } + if (response.status === 401) { + throw new AuthRequiredError() + }추가로 파일 상단 어딘가에 예외 클래스를 선언하세요(지원 코드):
class AuthRequiredError extends Error { status = 401 as const constructor(message = '로그인이 필요합니다.') { super(message) this.name = 'AuthRequiredError' } }Also applies to: 121-125
145-154: cursor 파라미터 네이밍 불일치 확인 필요(cursor vs cursorId)
- 목록/베스트는 cursorId, 사용자 목록은 cursor를 사용합니다. 백엔드 계약이 의도된 것인지 확인해 주세요. 통일 가능하면 파라미터명을 일관화하세요.
49-51: sortBy 허용값을 타입으로 제한
- 현재 문자열 자유 입력이라 서버 계약과 어긋날 위험이 있습니다. union 타입을 정의해 컴파일 타임에 방지하세요(예: 'CREATEDAT' | 'LIKES' 등).
타입 정의 예시(types.ts):
export type ResumeSort = 'CREATEDAT' | 'LIKES' | 'BOOKMARKS' export interface ResumeQueryParams { /* ... */ sortBy?: ResumeSort }
176-194: 요청 취소(AbortSignal) 및 타임아웃 지원 제안
- 사용자가 필터를 빠르게 변경할 때 낭비되는 네트워크를 줄이기 위해 AbortSignal을 옵션으로 지원하세요. 훅에서 signal 전달이 가능해집니다.
예시(요지):
-export async function getResumeList({ ... }: ResumeQueryParams) { +export async function getResumeList({ ... }: ResumeQueryParams, opts?: { signal?: AbortSignal }) { @@ - const response = await fetch(`${RESUME_API_BASE}?${params.toString()}`, { + const response = await fetch(`${RESUME_API_BASE}?${params.toString()}`, { method: 'GET', + signal: opts?.signal, })다른 fetch 호출에도 동일 패턴을 적용해 주세요.
218-223: 업로드 응답 타입 명시 및 서버 메시지 전파
- 반환 타입이 any입니다. 응답 스키마를 타입으로 정의하고 사용하세요. 또한 실패 시 서버의 에러 메시지를 최대한 전파하세요.
-export const uploadResume = async ( +export const uploadResume = async ( file: File, data: ResumeUploadRequest, -): Promise<any> => { +): Promise<UploadResumeResponse> => { @@ - return response.json() + return response.json() as Promise<UploadResumeResponse>지원 코드(types.ts):
export interface UploadResumeResponse { id: number createdAt: number title: string url: string category: string position: string likeCount: number user: { id: number; name: string; profileImage: string; year: number; mainPosition: string } }Also applies to: 247-248
28-35: 카테고리 매핑 통합 및 불일치 수정
- UI 전역에서 ‘전체 보기’/‘전체보기’, ‘기타’, ‘OTHERS’ 등 현재 categoryMap에 누락되거나 다르게 사용되는 라벨이 다수 발견되었습니다. 공용 enum 또는 상수 파일 하나에 한글→영문 매핑을 정의하고, 모든 사용처에서 이를 참조하도록 교체하세요.
src/app/(protected)/resume/@resumeList.tsx (2)
73-83: useCallback 의존성 배열 정합성(잠재적 stale 클로저) 확인
- fetchLikes/fetchBookmarks를 캡처하면서 의존성 배열을 빈 배열로 두면 교체 시 갱신되지 않습니다. 훅이 안정된 ref를 보장하지 않는다면 deps에 포함하세요. 불가피하면 eslint-disable 대신 안정 래퍼를 제공합니다.
- }, []) + }, [fetchLikes]) @@ - }, []) + }, [fetchBookmarks])Also applies to: 85-94
96-108: setTimeout 사용 시 언마운트 클린업 추가 권장
- 언마운트 직후 타이머 콜백이 실행되면 불필요한 refetch가 발생할 수 있습니다. timeout ref를 두고 정리하세요.
지원 코드:
const timeoutRef = useRef<NodeJS.Timeout | null>(null) const handleLikeUpdate = (...) => { if (timeoutRef.current) clearTimeout(timeoutRef.current) timeoutRef.current = setTimeout(() => { checkLike(); refetch() }, 500) } useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) }, [])또는, TanStack Query의 뮤테이션 onSuccess에서 invalidate를 호출해 타이머를 제거하는 방식을 고려해 주세요.
Also applies to: 110-115
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (14)
src/api/resume/apis.ts(1 hunks)src/api/resume/index.ts(1 hunks)src/api/resume/keys.ts(1 hunks)src/api/resume/mutations.ts(1 hunks)src/api/resume/queries.ts(1 hunks)src/api/resume/types.ts(1 hunks)src/app/(protected)/resume/@resumeList.tsx(5 hunks)src/app/(protected)/resume/[resumeId]/page.tsx(4 hunks)src/components/mypage/AddResume.tsx(3 hunks)src/components/mypage/Resume.tsx(6 hunks)src/components/mypage/ResumeFolder.tsx(4 hunks)src/components/resume/BestResume.tsx(4 hunks)src/components/resume/OtherResume.tsx(2 hunks)src/components/resume/ResumeFolder.tsx(4 hunks)
🧰 Additional context used
🧬 Code graph analysis (12)
src/api/resume/mutations.ts (3)
src/api/resume/types.ts (2)
LikeBookmarkRequest(51-56)ResumeUploadRequest(59-64)src/api/resume/apis.ts (3)
postLike(177-194)postBookmark(197-216)uploadResume(219-248)src/api/resume/keys.ts (1)
resumeKeys(4-23)
src/components/mypage/AddResume.tsx (1)
src/api/resume/mutations.ts (1)
useResumeUploadMutation(33-44)
src/components/resume/OtherResume.tsx (1)
src/api/resume/queries.ts (1)
useUserResumeListQuery(74-85)
src/components/resume/BestResume.tsx (1)
src/api/resume/queries.ts (1)
useBestResumeListQuery(57-71)
src/api/resume/queries.ts (3)
src/api/resume/types.ts (1)
ResumeQueryParams(2-9)src/api/resume/keys.ts (1)
resumeKeys(4-23)src/api/resume/apis.ts (4)
getResumeList(13-70)fetchResumeById(73-95)fetchBestResumes(98-136)fetchUserResumes(139-174)
src/api/resume/keys.ts (1)
src/api/resume/types.ts (1)
ResumeQueryParams(2-9)
src/components/mypage/ResumeFolder.tsx (1)
src/api/resume/mutations.ts (2)
useResumeLikeMutation(7-17)useResumeBookmarkMutation(20-30)
src/app/(protected)/resume/[resumeId]/page.tsx (2)
src/api/resume/queries.ts (1)
useResumeDetailQuery(46-54)src/components/profile/ProfileBox.tsx (1)
ProfileBox(40-145)
src/components/resume/ResumeFolder.tsx (1)
src/api/resume/mutations.ts (2)
useResumeLikeMutation(7-17)useResumeBookmarkMutation(20-30)
src/api/resume/apis.ts (1)
src/api/resume/types.ts (6)
ResumeQueryParams(2-9)ResumeDetail(12-27)BestResumeResponse(37-41)UserResumeResponse(44-48)LikeBookmarkRequest(51-56)ResumeUploadRequest(59-64)
src/components/mypage/Resume.tsx (4)
src/app/blog/_lib/useLike.ts (1)
useLike(3-51)src/app/blog/_lib/useBookmark.ts (1)
useBookmark(3-51)src/api/resume/queries.ts (1)
useUserResumeListQuery(74-85)src/components/mypage/AddResume.tsx (1)
AddResume(17-229)
src/app/(protected)/resume/@resumeList.tsx (2)
src/api/resume/queries.ts (1)
useResumeListQuery(12-43)src/components/mypage/Resume.tsx (1)
Resume(14-178)
🔇 Additional comments (13)
src/api/resume/index.ts (1)
1-5: 배럴 추가 LGTM단일 엔트리포인트로 정리되어 import 경로가 간결해집니다. 현재 구조에서는 부작용 없는 재Export라 리스크도 낮아 보입니다.
src/components/mypage/AddResume.tsx (1)
81-84: mutateAsync 인자 키 이름 확인
uploadMutation.mutateAsync({ file, data })의 키가uploadResume(file, data)시그니처와 정확히 일치하는지 확인 바랍니다(뮤테이션에서 그대로 전달 중). 런타임 오류 예방 차원에서 한 번 더 점검해 주세요.src/components/resume/OtherResume.tsx (1)
24-26: offset prop 미사용 확인
OtherResumeProps의 offset이 현재 사용되지 않습니다. 제거해도 되는지 의도 확인 부탁드립니다(부모 컴포넌트 영향 검토).src/components/resume/ResumeFolder.tsx (2)
10-13: TanStack Query 기반 뮤테이션 훅 도입 적절기존 직접 호출을 훅으로 치환한 방향성 좋습니다. 캐시 무효화 전략(resumeKeys.all)과도 일관됩니다.
73-74: 성공 시 부모 콜백 호출로 일관 업데이트 — OK
await이후에만onLikeUpdate/onBookmarkUpdate를 호출하는 구조는 적절합니다.Also applies to: 100-101
src/components/resume/BestResume.tsx (4)
1-4: 쿼리 훅 전환 👍수동 페이지네이션/로딩 상태를 React Query에 위임한 점 좋습니다.
35-41: 무한 스크롤 트리거 가드 적절
inView && hasNextPage && !isFetchingNextPage && isOpen가드로 중복 호출을 잘 방지하고 있습니다.
48-50: 페이지 데이터 형상 확인 필요
data?.pages.flatMap((page) => page.data)가정이 맞는지(각 페이지가{ data, hasNext, nextCursor }형태인지) 확인 필요합니다. 서버 응답이{content: [...]}등으로 바뀌면 빈 목록이 됩니다.
95-96: Sentinel 렌더 조건 👍
hasNextPage일 때만 sentinel을 렌더링해 불필요한 관찰을 막았습니다.src/api/resume/keys.ts (1)
4-7: 키 중앙화 및 as const 사용 👍키 정의를 한 곳으로 모으고 리터럴 타입 고정을 적용한 점 좋습니다.
src/api/resume/mutations.ts (1)
11-12: 카테고리 오용 방지 확인이 훅들은 이력서 전용입니다. 호출부에서
category: 'RESUME'가 항상 보장되는지 확인 부탁드립니다. 아닐 경우 내부에서 강제/검증 로직을 추가하면 안전합니다.Also applies to: 24-25
src/app/(protected)/resume/@resumeList.tsx (2)
123-125: pages 평탄화 시 null 안전성 OK — LGTM
- optional chaining으로 방어코드가 적절합니다.
136-145: 에러/빈 상태 합리적 처리 — LGTM
- isError 또는 데이터 없음 케이스에 공용 Empty 컴포넌트를 사용하는 흐름이 일관적입니다.
src/api/resume/mutations.ts
Outdated
| return useMutation({ | ||
| mutationFn: (data: LikeBookmarkRequest) => postLike(data), | ||
| onSuccess: () => { | ||
| // 좋아요 성공 시 관련 쿼리 무효화 | ||
| queryClient.invalidateQueries({ queryKey: resumeKeys.all }) | ||
| }, | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
무차별 캐시 무효화 대신 “타깃팅” + 타입 제네릭 추가를 권장
좋아요 후 resumeKeys.all 전체 무효화는 과도합니다. 상세/리스트/베스트만 갱신하도록 좁히고, useMutation 제네릭과 mutationKey를 추가해 타입 안전성과 디버깅 가독성을 높여주세요.
다음과 같이 변경 제안합니다:
- return useMutation({
- mutationFn: (data: LikeBookmarkRequest) => postLike(data),
- onSuccess: () => {
- // 좋아요 성공 시 관련 쿼리 무효화
- queryClient.invalidateQueries({ queryKey: resumeKeys.all })
- },
- })
+ return useMutation<void, Error, LikeBookmarkRequest>({
+ mutationKey: [...resumeKeys.all, 'like'],
+ mutationFn: postLike,
+ onSuccess: async (_data, variables) => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: resumeKeys.detail(variables.contentId) }),
+ queryClient.invalidateQueries({ queryKey: resumeKeys.lists() }),
+ queryClient.invalidateQueries({ queryKey: resumeKeys.bestList() }),
+ ])
+ },
+ })📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| return useMutation({ | |
| mutationFn: (data: LikeBookmarkRequest) => postLike(data), | |
| onSuccess: () => { | |
| // 좋아요 성공 시 관련 쿼리 무효화 | |
| queryClient.invalidateQueries({ queryKey: resumeKeys.all }) | |
| }, | |
| }) | |
| return useMutation<void, Error, LikeBookmarkRequest>({ | |
| mutationKey: [...resumeKeys.all, 'like'], | |
| mutationFn: postLike, | |
| onSuccess: async (_data, variables) => { | |
| await Promise.all([ | |
| queryClient.invalidateQueries({ queryKey: resumeKeys.detail(variables.contentId) }), | |
| queryClient.invalidateQueries({ queryKey: resumeKeys.lists() }), | |
| queryClient.invalidateQueries({ queryKey: resumeKeys.bestList() }), | |
| ]) | |
| }, | |
| }) |
🤖 Prompt for AI Agents
In src/api/resume/mutations.ts around lines 10 to 16, the mutation currently
invalidates resumeKeys.all which is too broad; update useMutation to include
proper generics (e.g. useMutation<ResponseType, Error, LikeBookmarkRequest>) and
add a descriptive mutationKey (e.g. ['resume','like']) and replace the single
invalidateQueries({ queryKey: resumeKeys.all }) with targeted invalidations such
as queryClient.invalidateQueries({ queryKey: resumeKeys.detail(id) }),
queryClient.invalidateQueries({ queryKey: resumeKeys.list() }), and
queryClient.invalidateQueries({ queryKey: resumeKeys.best() }) (use the
appropriate id or params from the mutation input) so only detail/list/best
caches refresh.
| const convertToResume = (item: any): Resume => ({ | ||
| id: item.id, | ||
| createdAt: item.createdAt, | ||
| title: item.title, | ||
| category: item.category, | ||
| position: item.position, | ||
| likeCount: item.likeCount, | ||
| year: item.year, | ||
| user: { | ||
| id: item.user.id, | ||
| name: item.user.name, | ||
| profileImage: item.user.profileImage, | ||
| year: item.user.year, | ||
| mainPosition: item.user.mainPosition, | ||
| }, | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
컨버터에서 타입 정규화(String(id), createdAt ms, year 기본값)
- const convertToResume = (item: any): Resume => ({
- id: item.id,
- createdAt: item.createdAt,
+ const convertToResume = (item: any): Resume => ({
+ id: String(item.id),
+ createdAt: new Date(item.createdAt).getTime(),
title: item.title,
category: item.category,
position: item.position,
likeCount: item.likeCount,
- year: item.year,
+ year: String(item.year ?? ''),
user: {
- id: item.user.id,
+ id: Number(item.user.id),
name: item.user.name,
profileImage: item.user.profileImage,
year: item.user.year,
mainPosition: item.user.mainPosition,
},
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const convertToResume = (item: any): Resume => ({ | |
| id: item.id, | |
| createdAt: item.createdAt, | |
| title: item.title, | |
| category: item.category, | |
| position: item.position, | |
| likeCount: item.likeCount, | |
| year: item.year, | |
| user: { | |
| id: item.user.id, | |
| name: item.user.name, | |
| profileImage: item.user.profileImage, | |
| year: item.user.year, | |
| mainPosition: item.user.mainPosition, | |
| }, | |
| }) | |
| const convertToResume = (item: any): Resume => ({ | |
| - id: item.id, | |
| id: String(item.id), | |
| createdAt: new Date(item.createdAt).getTime(), | |
| title: item.title, | |
| category: item.category, | |
| position: item.position, | |
| likeCount: item.likeCount, | |
| year: String(item.year ?? ''), | |
| user: { | |
| id: Number(item.user.id), | |
| name: item.user.name, | |
| profileImage: item.user.profileImage, | |
| year: item.user.year, | |
| mainPosition: item.user.mainPosition, | |
| }, | |
| }) |
🤖 Prompt for AI Agents
In src/app/(protected)/resume/@resumeList.tsx around lines 56 to 71, the
convertToResume mapper should normalize types: coerce id and nested user.id to
strings, ensure createdAt is a millisecond timestamp (handle string or number
inputs) and provide a default numeric year when missing; update the returned
object to use String(item.id) and String(item.user?.id), compute createdAt as
(typeof item.createdAt === 'number' ? item.createdAt : new
Date(item.createdAt).getTime()) and set year: typeof item.year === 'number' ?
item.year : 0 (and similarly for user.year default), ensuring all fields match
the Resume type.
src/components/mypage/Resume.tsx
Outdated
| const [likeList, setLikeList] = useState<string[]>([]) | ||
| const { fetchLikes } = useLike() | ||
| const [bookmarkList, setBookmarkList] = useState<string[]>([]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
좋아요/북마크 리스트 상태 타입 정교화.
아래처럼 요소가 id를 가진 객체임이 코드에 나타납니다. 상태 제네릭을 명확히 하세요.
- const [likeList, setLikeList] = useState<string[]>([])
- const [bookmarkList, setBookmarkList] = useState<string[]>([])
+ const [likeList, setLikeList] = useState<Array<{ id: number | string }>>([])
+ const [bookmarkList, setBookmarkList] = useState<Array<{ id: number | string }>>(
+ [],
+ )📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const [likeList, setLikeList] = useState<string[]>([]) | |
| const { fetchLikes } = useLike() | |
| const [bookmarkList, setBookmarkList] = useState<string[]>([]) | |
| const [likeList, setLikeList] = useState<Array<{ id: number | string }>>([]) | |
| const [bookmarkList, setBookmarkList] = useState<Array<{ id: number | string }>>( | |
| [], | |
| ) |
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app/(protected)/resume/[resumeId]/page.tsx (1)
70-89: a 버튼 중첩 제거: 인터랙티브 요소 중첩은 접근성/HTML 위반현재 button 안에 a가 중첩되어 있습니다. a 하나로 버튼 스타일을 주는 방식으로 교체하세요. 또한 cross-origin 다운로드 특성상
download파일명은 보장되지 않으므로 힌트용으로만 두고, 새 탭과 noopener를 함께 지정하세요.- {/* 다운로드 버튼 */} - <div className="flex flex-col gap-5"> - <button - className="flex justify-center items-center w-[2.5rem] h-[2.5rem] shadow-md border border-gray outline-none rounded-full" - type="button" - > - <a - href={pdfDownloadUrl} - download="name.pdf" - className="flex justify-center items-center w-[2rem] h-[2rem] outline-none" - > - <Image - src="/pdfdown.png" - width={20} - height={20} - alt="DownIMG" - /> - </a> - </button> - </div> + {/* 다운로드 버튼 */} + <div className="flex flex-col gap-5"> + <a + href={pdfDownloadUrl} + download + target="_blank" + rel="noopener noreferrer" + aria-label="PDF 다운로드" + className="flex justify-center items-center w-[2.5rem] h-[2.5rem] shadow-md border border-gray outline-none rounded-full" + > + <Image + src="/pdfdown.png" + width={20} + height={20} + alt="PDF 다운로드" + /> + </a> + </div>
♻️ Duplicate comments (1)
src/app/(protected)/resume/@resumeList.tsx (1)
56-71: 컨버터(변환기)에서 타입 정규화 누락: id/createdAt/year/user.id 안전 캐스팅 필요API 응답이 string/number 혼재일 수 있어 현재 타입(
Resume)과 불일치 위험이 큽니다. 아래처럼 안전 캐스팅과 기본값을 적용하세요.- const convertToResume = (item: any): Resume => ({ - id: item.id, - createdAt: item.createdAt, - title: item.title, - category: item.category, - position: item.position, - likeCount: item.likeCount, - year: item.year, - user: { - id: item.user.id, - name: item.user.name, - profileImage: item.user.profileImage, - year: item.user.year, - mainPosition: item.user.mainPosition, - }, - }) + const convertToResume = (item: any): Resume => ({ + id: String(item?.id), + createdAt: + typeof item?.createdAt === 'number' + ? item.createdAt + : new Date(item?.createdAt).getTime(), + title: String(item?.title ?? ''), + category: String(item?.category ?? ''), + position: String(item?.position ?? ''), + likeCount: Number(item?.likeCount ?? 0), + year: String(item?.year ?? ''), + user: { + id: Number(item?.user?.id), + name: String(item?.user?.name ?? ''), + profileImage: String(item?.user?.profileImage ?? ''), + year: Number(item?.user?.year ?? 0), + mainPosition: String(item?.user?.mainPosition ?? ''), + }, + })
🧹 Nitpick comments (10)
src/app/(protected)/resume/@resumeList.tsx (4)
96-108: setTimeout 기반 refetch: 언마운트 시 메모리 누수/중복 호출 위험타이머를 ref로 관리하고 언마운트 클린업을 추가하세요. 가능하면 뮤테이션 측
onSuccess에서 invalidate/refetch를 트리거하는 구조로 이동을 권장합니다.const handleLikeUpdate = (resumeId: string, newLikeCount: number) => { - setTimeout(() => { - checkLike() - refetch() - }, 500) + if (likeTimerRef.current) clearTimeout(likeTimerRef.current) + likeTimerRef.current = window.setTimeout(() => { + checkLike() + refetch() + }, 500) } const handleBookmarkUpdate = (resumeId: string, newBookmarkCount: number) => { - setTimeout(() => { - checkBookmark() - refetch() - }, 500) + if (bookmarkTimerRef.current) clearTimeout(bookmarkTimerRef.current) + bookmarkTimerRef.current = window.setTimeout(() => { + checkBookmark() + refetch() + }, 500) } + + useEffect(() => { + return () => { + if (likeTimerRef.current) clearTimeout(likeTimerRef.current) + if (bookmarkTimerRef.current) clearTimeout(bookmarkTimerRef.current) + } + }, [])추가 임포트/레퍼런스:
- import { useEffect, useState, useCallback } from 'react' + import { useEffect, useState, useCallback, useRef } from 'react'const likeTimerRef = useRef<number | null>(null) const bookmarkTimerRef = useRef<number | null>(null)
123-124: 큰 데이터에서 불필요한 평탄화 재계산 방지: useMemo 적용 권장렌더마다
flatMap비용이 발생합니다.data변경시에만 계산되도록 메모이제이션하세요.- const resumes = - data?.pages.flatMap((page) => page.data.map(convertToResume)) ?? [] + const resumes = useMemo( + () => data?.pages.flatMap((page) => page.data.map(convertToResume)) ?? [], + [data], + )임포트:
- import { useEffect, useState, useCallback } from 'react' + import { useEffect, useState, useCallback, useMemo } from 'react'
136-144: 에러 상태와 빈 목록 상태 분리 렌더링현재 둘을 합쳐 처리해 에러 시 기존 데이터가 있어도 빈 화면이 나올 수 있습니다. 분리해 사용자 메시지를 명확히 하세요.
- if (isError || (!isLoading && resumes.length === 0)) { - return ( - <div className="flex justify-center"> - <EmptyAnimation - text="이력서 데이터가 없습니다." - text2="다시 조회해주세요" - /> - </div> - ) - } + if (isError) { + return ( + <div className="flex justify-center"> + <EmptyAnimation text="이력서 목록을 불러오지 못했습니다." text2="잠시 후 다시 시도해주세요" /> + </div> + ) + } + if (!isLoading && resumes.length === 0) { + return ( + <div className="flex justify-center"> + <EmptyAnimation text="이력서 데이터가 없습니다." text2="필터를 변경하거나 다시 조회해주세요" /> + </div> + ) + }
11-26: Resume 타입 중복 정의: 공용 타입으로 이동동일/유사 타입이 여러 컴포넌트에 산재하면 유지보수 비용이 큽니다.
src/api/resume/types(또는 전역@/types)로 이동해 단일 소스로 관리하세요.convertToResume도 API 어댑터 레이어로 옮기면 재사용이 용이합니다.src/app/(protected)/resume/[resumeId]/page.tsx (6)
12-14: URL 파라미터 가드: NaN/비양수 ID 대응 및 쿼리키 안정화
Number(resumeId)가 NaN/0이면enabled가 false라 호출은 막히지만, 쿼리키에 NaN이 들어가면 캐시 키가 불안정해질 수 있습니다. 초기에 유효성 검사 값을 만들어 두고, 훅 인자로도 동일 값을 넘기면 관리가 수월합니다. invalid이면 즉시 사용자 피드백을 반환하는 게 UX에도 좋습니다.export default function Detail() { - const { resumeId } = useParams() as { resumeId: string } + const { resumeId } = useParams() as { resumeId: string } + const id = Number(resumeId) + const isValidId = Number.isFinite(id) && id > 0 @@ - } = useResumeDetailQuery(Number(resumeId)) + } = useResumeDetailQuery(id) + + if (!isValidId) { + return ( + <div className="w-[55.625rem] text-center text-gray"> + 유효하지 않은 이력서 ID입니다. + </div> + ) + }Also applies to: 18-24, 25-25
33-39: 에러 처리 조건 수정: stale 데이터가 있으면 우선 렌더링캐시에 유효 데이터가 있는데 최신 요청이 실패한 경우
isError || !resume는 에러 화면을 보여줍니다. stale-while-revalidate UX를 살리려면 데이터가 없을 때만 에러로 처리하세요.- if (isError || !resume) { + if (isError && !resume) {
42-55: Google Drive 링크 파싱 보강 필요현 로직은
/d/패턴만 지원합니다.open?id=...등 다른 드라이브 링크 포맷이 오면 오탐/미탐이 납니다. URL 기반 파싱과 예외 처리로 보강을 권장합니다.- if (!resume.url || !resume.url.includes('/d/')) { - // url이 없거나 Drive 링크 아님 - return ( - <div className="w-[55.625rem] text-center text-gray"> - 등록된 이력서가 없습니다. - </div> - ) - } - - // 정상 데이터 - const driveId = resume.url.split('/d/')[1].split('/')[0] + if (!resume.url) { + return ( + <div className="w-[55.625rem] text-center text-gray"> + 등록된 이력서가 없습니다. + </div> + ) + } + // Drive 링크 파싱 (file/d/{id} 또는 open?id={id}) + let driveId: string | null = null + try { + const u = new URL(resume.url) + const isDrive = u.hostname.includes('drive.google.com') + const match = u.pathname.match(/\/d\/([^/]+)/) + driveId = match?.[1] ?? u.searchParams.get('id') + if (!isDrive || !driveId) { + return ( + <div className="w-[55.625rem] text-center text-gray"> + 등록된 이력서가 없습니다. + </div> + ) + } + } catch { + return ( + <div className="w-[55.625rem] text-center text-gray"> + 등록된 이력서가 없습니다. + </div> + ) + }Also applies to: 51-55
61-67: iframe 소소한 개선: 지연 로딩·리퍼러 최소화초기 페인트/네트워크 절약과 프라이버시 보호를 위해 속성 추가를 권장합니다.
- <iframe + <iframe src={pdfPreviewUrl} width="100%" height="100%" frameBorder="0" title="Resume PDF" - ></iframe> + loading="lazy" + referrerPolicy="no-referrer" + />
104-122: ProfileBox 로딩/에러 전달 정밀화 및 메시지 표출초기 로딩 이외의 백그라운드 refetch에서는 스켈레톤을 숨기고, 에러는 메시지로 전달하면 좌측 박스 UX가 개선됩니다.
- loading={isLoading} - error={isError ? 'Error' : ''} + loading={isLoading && !resume} + error={ + isError && !resume + ? (error instanceof Error ? error.message : '오류가 발생했습니다.') + : '' + }
104-119: PII 마스킹은 컴포넌트가 아닌 데이터 계층에서 일괄 처리 권장email/학교/블로그 URL 등을 화면단에서 공백으로 덮는 방식은 누락/회귀 위험이 큽니다. 서버 응답 스키마 수준(또는 React Query의
select트랜스포머)에서 PII 제거/마스킹을 일괄 적용해 중복과 실수를 줄이세요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
src/app/(protected)/resume/@resumeList.tsx(5 hunks)src/app/(protected)/resume/[resumeId]/page.tsx(4 hunks)src/components/mypage/AddResume.tsx(3 hunks)src/components/mypage/ResumeFolder.tsx(4 hunks)src/components/resume/ResumeFolder.tsx(4 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
- src/components/mypage/AddResume.tsx
- src/components/mypage/ResumeFolder.tsx
- src/components/resume/ResumeFolder.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
src/app/(protected)/resume/@resumeList.tsx (3)
src/api/resume/queries.ts (1)
useResumeListQuery(12-43)src/components/mypage/Resume.tsx (1)
Resume(14-178)src/components/resume/ResumeFolder.tsx (1)
ResumeFolder(23-197)
src/app/(protected)/resume/[resumeId]/page.tsx (2)
src/api/resume/queries.ts (1)
useResumeDetailQuery(46-54)src/components/profile/ProfileBox.tsx (1)
ProfileBox(15-120)
🔇 Additional comments (3)
src/app/(protected)/resume/@resumeList.tsx (1)
32-33: sortBy 상수 값 정확성 확인 및 타입 정의 보강
기본값으로 사용 중인 'CREATEDAT'이 백엔드 API와 타입 정의에 정확히 일치하는지 검증하세요. 현재ResumeQueryParams.sortBy가string으로만 정의되어 있어, 컴파일 타임 검증을 위해 허용 값을 union type 또는 enum으로 명시하는 것을 권장합니다.src/app/(protected)/resume/[resumeId]/page.tsx (2)
18-24: TanStack Query 전환 잘 적용됐습니다.훅으로 데이터 흐름 일원화, staleTime/gcTime 설정 모두 적절합니다. 👍
131-133: Other 컴포넌트 페이징 파라미터(offset/limit) 확인 필요src/app/(protected)/resume/[resumeId]/page.tsx (131–133):
{showOther && resume?.user && ( <Other id={resume.user.id} offset={0} limit={10} /> )}–
offset={0},limit={10}하드코딩이 의도된 값인지, 특히 무한 스크롤 구현 시cursor/nextPageParam방식과 일치하는지 Other 컴포넌트 정의를 직접 확인하고 검증해주세요.
| const [likeList, setLikeList] = useState<string[]>([]) | ||
| const [bookmarkList, setBookmarkList] = useState<string[]>([]) | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
likeList/bookmarkList 타입과 ResumeFolder 기대 형태 불일치로 런타임 에러 가능
ResumeFolder 내부에서 likeList.some((v) => v.id === resume.id)를 사용합니다. 이 파일에서는 likeList, bookmarkList를 string[]로 관리해 v.id 접근 시 크래시가 납니다. 리스트를 { id: string }[]로 정규화해 전달하세요. 또한 useCallback 의존성에 fetchLikes/fetchBookmarks를 포함해 레퍼런스가 교체되는 경우를 방지하세요.
적용 diff:
- const [likeList, setLikeList] = useState<string[]>([])
- const [bookmarkList, setBookmarkList] = useState<string[]>([])
+ const [likeList, setLikeList] = useState<{ id: string }[]>([])
+ const [bookmarkList, setBookmarkList] = useState<{ id: string }[]>([])
const checkLike = useCallback(async () => {
try {
- const data = await fetchLikes('RESUME', 0, 50)
- setLikeList(data)
+ const data = await fetchLikes('RESUME', 0, 50)
+ const normalized =
+ Array.isArray(data) ? data.map((v: any) => ({ id: String(v?.id ?? v) })) : []
+ setLikeList(normalized)
return data
} catch (err) {
console.error(err)
return []
}
- }, [])
+ }, [fetchLikes])
const checkBookmark = useCallback(async () => {
try {
- const data = await fetchBookmarks('RESUME', 0, 50)
- setBookmarkList(data)
+ const data = await fetchBookmarks('RESUME', 0, 50)
+ const normalized =
+ Array.isArray(data) ? data.map((v: any) => ({ id: String(v?.id ?? v) })) : []
+ setBookmarkList(normalized)
return data
} catch (err) {
console.error(err)
return []
}
- }, [])
+ }, [fetchBookmarks])Also applies to: 73-94
🤖 Prompt for AI Agents
In src/app/(protected)/resume/@resumeList.tsx around lines 35-37 (and also
applies to 73-94), likeList and bookmarkList are typed as string[] but
ResumeFolder expects items with an id property (e.g. { id: string }), causing
runtime crashes when accessing v.id; change the state types to { id: string }[]
(normalize any places that populate these lists to that shape), and add
fetchLikes and fetchBookmarks to the dependency arrays of the useCallback hooks
so their references are stable and callbacks update correctly when those
functions change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app/(protected)/resume/[resumeId]/page.tsx (1)
71-89: a 태그를 button 안에 중첩 — 접근성/시맨틱 이슈인터랙티브 요소 중첩은 스크린 리더·키보드 포커스에 문제를 일으킵니다. a 단일 요소로 버튼 스타일을 적용하세요. 또한 cross-origin(Drive)에서는 download 속성이 무시될 수 있어, 새 탭 열기와 rel 보호가 필요합니다.
적용 제안(diff):
- {/* 다운로드 버튼 */} - <div className="flex flex-col gap-5"> - <button - className="flex justify-center items-center w-[2.5rem] h-[2.5rem] shadow-md border border-gray outline-none rounded-full" - type="button" - > - <a - href={pdfDownloadUrl} - download="name.pdf" - className="flex justify-center items-center w-[2rem] h-[2rem] outline-none" - > - <Image - src="/pdfdown.png" - width={20} - height={20} - alt="DownIMG" - /> - </a> - </button> - </div> + {/* 다운로드 버튼 */} + <div className="flex flex-col gap-5"> + <a + href={pdfDownloadUrl} + target="_blank" + rel="noopener noreferrer" + className="flex justify-center items-center w-[2.5rem] h-[2.5rem] shadow-md border border-gray outline-none rounded-full" + aria-label="PDF 다운로드" + > + <Image src="/pdfdown.png" width={20} height={20} alt="PDF 다운로드" /> + </a> + </div>
🧹 Nitpick comments (8)
src/app/(protected)/resume/[resumeId]/page.tsx (8)
12-14: resumeId 파싱/검증 추가로 NaN·비정상 값 방지useParams는 string | string[] 가능성이 있어 Number(resumeId)로 바로 변환 시 NaN이 나올 수 있습니다. 안전 파싱 후 훅에 전달하고, 잘못된 ID는 조기 반환하는 편이 명확합니다.
적용 제안(diff):
export default function Detail() { - const { resumeId } = useParams() as { resumeId: string } + const params = useParams() + const rawResumeId = Array.isArray((params as any)?.resumeId) + ? (params as any).resumeId[0] + : (params as any)?.resumeId + const parsedResumeId = Number.parseInt(rawResumeId ?? '', 10)그리고 Line 24의 훅 호출도 아래와 같이 교체:
- } = useResumeDetailQuery(Number(resumeId)) + } = useResumeDetailQuery(parsedResumeId)
18-25: 쿼리 error 미사용 및 메시지 손실 — 의미 있는 에러 전달error를 구조분해만 하고 실제 UI에는 'Error' 리터럴을 넘겨 상세 메시지를 잃고 있습니다. 실제 에러 메시지를 안전하게 추출해 재사용하세요.
적용 제안(diff):
const { data: resume, isLoading, isError, - error, + error: queryError, } = useResumeDetailQuery(parsedResumeId) + + const errorMessage = + isError + ? (queryError instanceof Error + ? queryError.message + : '이력서를 불러오는 중 오류가 발생했습니다.') + : ''
31-39: 오류 렌더링 시 실제 메시지 노출위에서 만든 errorMessage를 사용하여 사용자에게 구체적인 오류 원인을 보여주는 편이 UX에 좋습니다.
적용 제안(diff):
- if (isError || !resume) { + if (isError || !resume) { // 에러 상태 or resume가 null return ( <div className="w-[55.625rem] text-center text-gray"> - 이력서를 불러오는 중 오류가 발생했습니다. + {errorMessage || '이력서를 불러오는 중 오류가 발생했습니다.'} </div> ) }
42-55: Google Drive 링크 감지/파싱 로직 보강'/d/' 패턴만 처리하면 open?id=, uc?id= 형태를 놓칩니다. 공용 정규식으로 id를 추출하고, 실패 시 사용자 메시지를 명확히 하세요.
적용 제안(diff):
- if (!resume.url || !resume.url.includes('/d/')) { - // url이 없거나 Drive 링크 아님 - return ( - <div className="w-[55.625rem] text-center text-gray"> - 등록된 이력서가 없습니다. - </div> - ) - } - - // 정상 데이터 - const driveId = resume.url.split('/d/')[1].split('/')[0] + if (!resume.url) { + return ( + <div className="w-[55.625rem] text-center text-gray">등록된 이력서가 없습니다.</div> + ) + } + const driveId = extractDriveId(resume.url) + if (!driveId) { + return ( + <div className="w-[55.625rem] text-center text-gray"> + 지원하지 않는 이력서 링크 형식입니다. Google Drive 공유 링크를 등록해주세요. + </div> + ) + } const pdfPreviewUrl = `https://drive.google.com/file/d/${driveId}/preview` const pdfDownloadUrl = `https://drive.google.com/uc?export=download&id=${driveId}`헬퍼 함수(컴포넌트 바깥에 추가):
function extractDriveId(url: string): string | null { const byPath = url.match(/\/d\/([a-zA-Z0-9_-]+)/)?.[1] if (byPath) return byPath const byQuery = url.match(/[?&]id=([a-zA-Z0-9_-]+)/)?.[1] return byQuery || null }
58-65: iframe 보안/성능 옵션 고려외부 도메인 임베드이므로 sandbox/allow/referrerPolicy/loading 등을 검토하세요. Drive Viewer 제약으로 전부 적용은 어려울 수 있으나 최소한 loading="lazy"와 referrerPolicy는 무난합니다.
적용 제안(diff):
- <iframe + <iframe src={pdfPreviewUrl} width="100%" height="100%" frameBorder="0" title="Resume PDF" + loading="lazy" + referrerPolicy="no-referrer" ></iframe>
103-121: ProfileBox에 전달하는 profile 필드 정제 방식과 타입 확인
- 불필요한 필드(isLft)가 보입니다. 오타(isLeft?) 또는 미사용 필드라면 제거하세요.
- 민감정보 블랭크 처리 자체는 OK. 다만 ProfileBox의 요구 스키마에 맞춰 최소 필드만 명시하는 편이 안전합니다(초과 속성으로 인한 Excess Property 체크 회피).
적용 제안(diff):
? { ...resume.user, email: '', school: '', grade: '', subPosition: '', githubUrl: '', mediumUrl: '', velogUrl: '', tistoryUrl: '', - isLft: false, } : undefined원하시면 Profile 타입에 맞춘 안전 매퍼 함수를 추가해 드릴게요.
121-123: ProfileBox error prop에 실제 메시지 전달현재 'Error' 리터럴은 UX가 떨어지고, ProfileBox 내부 로직에 따라 스켈레톤만 보일 수 있습니다. 위에서 만든 errorMessage를 그대로 넘기는 편이 낫습니다.
적용 제안(diff):
- error={isError ? 'Error' : ''} + error={errorMessage}참고: ProfileBox.tsx는
if (loading || error) return <SkeletonProfileBox />다음 줄에if (error)가 있어 에러 메시지가 사실상 노출되지 않습니다. 해당 컴포넌트 로직도 함께 정리 필요합니다.
58-60: 높이 값 불일치로 인한 오버플로 가능성우측 컨테이너 h-[50rem]인데 내부 PDF 래퍼가 h-[55rem]라 스크롤/잘림이 의도치 않게 발생할 수 있습니다. 동일 값으로 맞추거나 overflow 컨트롤을 명시하세요.
Also applies to: 96-101
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
src/app/(protected)/resume/[resumeId]/page.tsx(4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/app/(protected)/resume/[resumeId]/page.tsx (2)
src/api/resume/queries.ts (1)
useResumeDetailQuery(46-54)src/components/profile/ProfileBox.tsx (1)
ProfileBox(15-120)
2ruuuu
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
고생하셨습니다
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
♻️ Duplicate comments (4)
src/components/resume/ResumeFolder.tsx (1)
39-40:bookmarkCount초기화 버그
bookmarkCount가initialLikeCount로 초기화되어 잘못된 북마크 수를 표시합니다.mypage/ResumeFolder.tsx와 동일한 문제입니다.const [likeCount, setLikeCount] = useState(initialLikeCount) - const [bookmarkCount, setBookmarkCount] = useState(initialLikeCount) + const [bookmarkCount, setBookmarkCount] = useState(0)src/components/mypage/Resume.tsx (1)
17-18:likeList/bookmarkList타입 개선 필요현재
string[]로 선언되어 있지만, 실제로는{ id: number | string }형태의 객체 배열로 사용됩니다 (예:bookmark.id접근). 타입을 정확히 명시해야 합니다.- const [bookmarkList, setBookmarkList] = useState<string[]>([]) - const [likeList, setLikeList] = useState<string[]>([]) + const [bookmarkList, setBookmarkList] = useState<Array<{ id: number | string }>>([]) + const [likeList, setLikeList] = useState<Array<{ id: number | string }>>([])src/components/mypage/ResumeFolder.tsx (1)
37-38:bookmarkCount초기화 버그
bookmarkCount가initialLikeCount로 초기화되어 있어 북마크 수가 좋아요 수로 표시됩니다. 서버에서 북마크 카운트를 제공하는 경우 해당 값을 사용하고, 그렇지 않으면0으로 초기화해야 합니다.const [likeCount, setLikeCount] = useState(initialLikeCount) - const [bookmarkCount, setBookmarkCount] = useState(initialLikeCount) + const [bookmarkCount, setBookmarkCount] = useState(0) // 또는 서버 제공 값 사용src/app/(protected)/resume/@resumeList.tsx (1)
35-36: likeList/bookmarkList 구조 불일치로 인한 런타임 오류 가능 + useCallback 의존성 보완 필요현재 상태:
likeList,bookmarkList가string[]로 관리되고,checkLike,checkBookmark에서setLikeList(data),setBookmarkList(data)로 그대로 넣고 있습니다.- 하지만
ResumeFolder에서는likeList.some((bookmark) => String(bookmark.id) === String(resume.id))형태로bookmark.id에 접근하므로, 배열 원소가 문자열이면 런타임 에러가 발생할 수 있습니다. (이전에 이미 지적되었던 부분과 동일 이슈입니다.)또한:
checkLike,checkBookmark는useCallback의 deps 배열이[]라fetchLikes,fetchBookmarks레퍼런스가 바뀌어도 콜백이 갱신되지 않습니다.권장 수정:
- 상태 타입을
{ id: string }[]로 변경 + normalize 적용- const [likeList, setLikeList] = useState<string[]>([]) - const [bookmarkList, setBookmarkList] = useState<string[]>([]) + const [likeList, setLikeList] = useState<{ id: string }[]>([]) + const [bookmarkList, setBookmarkList] = useState<{ id: string }[]>([])- const checkLike = useCallback(async () => { + const checkLike = useCallback(async () => { try { const data = await fetchLikes('RESUME', 0, 50) - setLikeList(data) + const normalized = Array.isArray(data) + ? data.map((v: any) => ({ id: String(v?.id ?? v) })) + : [] + setLikeList(normalized) return data } catch (err) { console.error(err) return [] } - }, []) + }, [fetchLikes])- const checkBookmark = useCallback(async () => { + const checkBookmark = useCallback(async () => { try { const data = await fetchBookmarks('RESUME', 0, 50) - setBookmarkList(data) + const normalized = Array.isArray(data) + ? data.map((v: any) => ({ id: String(v?.id ?? v) })) + : [] + setBookmarkList(normalized) return data } catch (err) { console.error(err) return [] } - }, []) + }, [fetchBookmarks])이렇게 하면
ResumeFolder에서 기대하는{ id: string }[]형태와 일치하고,fetchLikes/fetchBookmarks레퍼런스 변경에도 안전해집니다. (이전 리뷰 코멘트에서 제안된 내용과 동일 성격입니다.)Also applies to: 73-94
🧹 Nitpick comments (12)
src/components/resume/OtherResume.tsx (1)
17-21: 미사용offsetprop 제거 권장
OtherResumeProps인터페이스에offset이 정의되어 있지만 컴포넌트 내에서 사용되지 않습니다. 불필요한 prop은 제거하는 것이 좋습니다.interface OtherResumeProps { id: number - offset: number limit: number } -export default function OtherResume({ id, offset, limit }: OtherResumeProps) { +export default function OtherResume({ id, limit }: OtherResumeProps) {src/api/resume/mutations.ts (1)
29-36: 업로드 뮤테이션에 제네릭 추가 권장
useResumeBookmarkMutation과 일관성을 위해useResumeUploadMutation에도 제네릭 타입을 명시하는 것이 좋습니다. 타입 안전성과 디버깅 가독성이 향상됩니다.- return useMutation({ - mutationFn: ({ file, data }: { file: File; data: ResumeUploadRequest }) => - uploadResume(file, data), + return useMutation<unknown, Error, { file: File; data: ResumeUploadRequest }>({ + mutationKey: [...resumeKeys.all, 'upload'], + mutationFn: ({ file, data }) => uploadResume(file, data), onSuccess: () => {src/components/mypage/ResumeFolder.tsx (1)
29-30: 미사용postBookmarkimport 제거
useBookmark에서 가져온postBookmark가bookmarkMutation으로 대체되었으므로 더 이상 사용되지 않습니다.const { postLike } = useLike() - const { postBookmark } = useBookmark()
useBookmarkimport 자체도 제거할 수 있습니다.src/components/resume/ResumeFolder.tsx (1)
29-34: 미사용 코드 제거
postBookmark와resumes상태가 사용되지 않습니다.const { postLike } = useLike() - const { postBookmark } = useBookmark() - - const bookmarkMutation = useResumeBookmarkMutation() - - const [resumes, setResumes] = useState<Resume[]>([]) + const bookmarkMutation = useResumeBookmarkMutation()
useBookmarkimport도 함께 제거할 수 있습니다.src/api/resume/queries.ts (2)
28-38: 부정 조건문을 긍정 조건문으로 변경SonarCloud 경고대로,
!pageParam대신 긍정 조건문을 사용하면 가독성이 향상됩니다.queryFn: ({ pageParam }) => { - // 첫 페이지는 initialLimit, 이후 페이지는 pageLimit - const limit = !pageParam ? initialLimit : pageLimit + // 첫 페이지는 initialLimit, 이후 페이지는 pageLimit + const limit = pageParam ? pageLimit : initialLimit return getResumeList({
60-74: queryFn 내 사이드 이펙트 분리 권장
setAuthModalOpen을queryFn에 전달하여 401 응답 시 모달을 여는 것은 쿼리 함수의 순수성을 해칩니다.onError콜백에서 인증 오류를 처리하는 것이 더 적절합니다.export const useBestResumeListQuery = ( - setAuthModalOpen: (open: boolean) => void, limit: number = 12, ) => { return useInfiniteQuery({ queryKey: [...resumeKeys.bestList(), limit], - queryFn: ({ pageParam }) => - fetchBestResumes(pageParam, limit, setAuthModalOpen), + queryFn: ({ pageParam }) => fetchBestResumes(pageParam, limit), initialPageParam: undefined as number | undefined, getNextPageParam: (lastPage) => lastPage.hasNext ? lastPage.nextCursor : undefined, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, + meta: { requiresAuth: true }, // 호출 측에서 에러 핸들링 }) }이 변경은
fetchBestResumesAPI 함수도 함께 수정해야 합니다.src/components/mypage/Resume.tsx (2)
60-72:setTimeout대신 뮤테이션 완료 후 refetch 권장500ms 지연은 임의의 값이며 네트워크 상황에 따라 데이터가 동기화되지 않을 수 있습니다. 좋아요/북마크 뮤테이션 훅의
onSuccess에서 refetch를 트리거하거나, 부모 컴포넌트에서 뮤테이션 완료를 기다리는 것이 더 안정적입니다.const handleLikeUpdate = (resumeId: string, newLikeCount: number) => { - setTimeout(() => { - checkLike() - refetch() - }, 500) + checkLike() + // 뮤테이션 훅의 onSuccess에서 이미 쿼리 무효화가 처리됨 }
91-112: 데이터 변환 메모이제이션 권장
resumes배열 변환이 매 렌더링마다 실행됩니다.useMemo를 사용하여data가 변경될 때만 재계산하도록 최적화하세요.+import { useCallback, useEffect, useMemo, useState } from 'react' - // 모든 페이지의 데이터를 평탄화하고 타입 변환 - const resumes = - data?.pages.flatMap((page) => + // 모든 페이지의 데이터를 평탄화하고 타입 변환 + const resumes = useMemo(() => + data?.pages.flatMap((page) => page.data.map((item) => ({ // ... mapping logic })), - ) ?? [] + ) ?? [], + [data?.pages, userId] + )src/api/resume/apis.ts (2)
12-70: 불필요한 try/catch 제거로 Sonar 경고 및 보일러플레이트 줄이기 제안
getResumeList,fetchResumeById,fetchBestResumes,fetchUserResumes모두try { ... } catch (error) { throw error }패턴이라 예외 전파 동작은 동일하지만 코드만 길어지고 Sonar 경고가 발생합니다.각 함수에서 특별한 정리/로깅이 없다면
try/catch를 제거하고, 내부에서response.ok체크 후 바로throw하는 형태로 단순화하는 것을 권장합니다.-export async function getResumeList(...) { - try { - ... - const result = await response.json() - return result - } catch (error) { - throw error // 에러를 호출한 함수에 다시 전달 - } -} +export async function getResumeList(...) { + ... + if (!response.ok) { + throw new Error( + `Error: 이력서 목록 조회 실패: ${response.status} ${response.statusText}`, + ) + } + return response.json() +}아래 다른 fetch 함수들도 동일한 방식으로 정리하면 가독성과 정적 분석 품질이 좋아집니다.
Also applies to: 72-95, 97-136, 138-174
152-154: fetchUserResumes의 nested 템플릿 리터럴 단순화
url생성 시 중첩 템플릿 리터럴을 사용하고 있어 Sonar 경고가 발생하는 부분입니다. 한 번 base URL을 만든 뒤 조건부로 쿼리스트링을 붙이면 더 읽기 쉽고 경고도 사라집니다.- const queryString = params.toString() - const url = `${RESUME_API_BASE}/user/${userId}${queryString ? `?${queryString}` : ''}` + const queryString = params.toString() + const baseUrl = `${RESUME_API_BASE}/user/${userId}` + const url = queryString ? `${baseUrl}?${queryString}` : baseUrl동작은 동일하면서 의도도 더 명확해집니다.
src/app/(protected)/resume/@resumeList.tsx (2)
56-71: convertToResume 정규화 보완 (createdAt/year 기본값 처리)기본적인 정규화는 잘 되어 있지만, 몇 가지 수정을 하면 안전성이 조금 더 올라갑니다.
createdAt이 이미 number일 수도 있어서, 타입이 섞여 들어올 경우를 대비해 한 번 분기해두면 불필요한Date생성이 줄어듭니다.year가undefined/null일 때String(item.year)는'undefined'/'null'이 되어 UI에 그대로 노출될 수 있습니다.예시:
- const convertToResume = (item: any): Resume => ({ - id: String(item.id), - createdAt: new Date(item.createdAt).getTime(), + const convertToResume = (item: any): Resume => ({ + id: String(item.id), + createdAt: + typeof item.createdAt === 'number' + ? item.createdAt + : new Date(item.createdAt).getTime(), title: item.title, category: item.category, position: item.position, likeCount: item.likeCount, - year: String(item.year), + year: String(item.year ?? ''),백엔드 응답이 항상 완전하다면 필수는 아니지만, 방어적으로 처리해두면 예외 상황에서 UI가 좀 더 안전해집니다.
96-108: handleLikeUpdate/handleBookmarkUpdate의 setTimeout 및 미사용 파라미터 정리 제안
handleLikeUpdate,handleBookmarkUpdate가(resumeId, newLikeCount/newBookmarkCount)를 인자로 받지만 내부에서는 사용하지 않고, 500mssetTimeout후checkLike+refetch만 호출하고 있습니다.
- 파라미터를 사용하지 않을 거라면 제거하거나,
_prefix로 의도적으로 미사용임을 표시하면 가독성이 좋아집니다.- 서버 반영 타이밍을 기다리려는 목적이라면, 해당 뮤테이션 훅(
useResumeLikeMutation/useResumeBookmarkMutation)의onSuccess에서refetch를 호출하는 쪽이 로직 위치상 더 자연스럽고, 매직 넘버(500ms)를 없앨 수 있습니다.예를 들어 이 컴포넌트에서는 단순히 콜백 시그니처만 맞추고, 내부에서는 바로
checkLike()/refetch()를 호출하도록 변경할 수 있습니다.- const handleLikeUpdate = (resumeId: string, newLikeCount: number) => { - setTimeout(() => { - checkLike() - refetch() - }, 500) - } + const handleLikeUpdate = () => { + checkLike() + refetch() + }Bookmark도 동일하게 정리하면 됩니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
src/api/resume/apis.ts(1 hunks)src/api/resume/mutations.ts(1 hunks)src/api/resume/queries.ts(1 hunks)src/app/(protected)/resume/@resumeList.tsx(5 hunks)src/components/mypage/Resume.tsx(6 hunks)src/components/mypage/ResumeFolder.tsx(4 hunks)src/components/resume/OtherResume.tsx(2 hunks)src/components/resume/ResumeFolder.tsx(5 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-03-30T06:39:04.042Z
Learnt from: Ye0ngjun
Repo: Techeer-Hogwarts/frontend PR: 68
File: src/hooks/search/useSearchResults.ts:4-17
Timestamp: 2025-03-30T06:39:04.042Z
Learning: In TypeScript development for the frontend project, avoid using 'any' type and prefer defining specific interfaces or types for better type safety and code completion. For search results, a SearchResultItem interface with at least id and title properties should be used instead of any[].
Applied to files:
src/components/mypage/Resume.tsx
🧬 Code graph analysis (8)
src/components/mypage/ResumeFolder.tsx (2)
src/api/resume/mutations.ts (1)
useResumeBookmarkMutation(7-23)src/api/likes/likes.ts (1)
postLike(6-30)
src/components/resume/ResumeFolder.tsx (2)
src/api/resume/mutations.ts (1)
useResumeBookmarkMutation(7-23)src/api/likes/likes.ts (1)
postLike(6-30)
src/api/resume/mutations.ts (3)
src/api/resume/types.ts (2)
LikeBookmarkRequest(51-56)ResumeUploadRequest(59-64)src/api/resume/keys.ts (1)
resumeKeys(4-23)src/api/resume/apis.ts (2)
postBookmark(177-196)uploadResume(199-228)
src/components/mypage/Resume.tsx (5)
src/app/(protected)/resume/page.tsx (1)
Resume(16-195)src/app/blog/_lib/useLike.ts (1)
useLike(3-51)src/app/blog/_lib/useBookmark.ts (1)
useBookmark(3-51)src/api/resume/queries.ts (1)
useUserResumeListQuery(77-88)src/components/mypage/AddResume.tsx (1)
AddResume(15-224)
src/components/resume/OtherResume.tsx (1)
src/api/resume/queries.ts (1)
useUserResumeListQuery(77-88)
src/api/resume/apis.ts (2)
src/app/(protected)/resume/api/getResumeList.ts (1)
getResumeList(3-60)src/api/resume/types.ts (6)
ResumeQueryParams(2-9)ResumeDetail(12-27)BestResumeResponse(37-41)UserResumeResponse(44-48)LikeBookmarkRequest(51-56)ResumeUploadRequest(59-64)
src/app/(protected)/resume/@resumeList.tsx (2)
src/api/resume/queries.ts (1)
useResumeListQuery(12-46)src/components/resume/ResumeFolder.tsx (1)
ResumeFolder(20-197)
src/api/resume/queries.ts (4)
src/api/resume/types.ts (1)
ResumeQueryParams(2-9)src/api/resume/keys.ts (1)
resumeKeys(4-23)src/api/resume/apis.ts (4)
getResumeList(13-70)fetchResumeById(73-95)fetchBestResumes(98-136)fetchUserResumes(139-174)src/app/(protected)/resume/api/getResumeList.ts (1)
getResumeList(3-60)
🪛 GitHub Check: SonarCloud Code Analysis
src/components/mypage/Resume.tsx
[warning] 14-14: Mark the props of the component as read-only.
src/api/resume/apis.ts
[warning] 153-153: Refactor this code to not use nested template literals.
[warning] 67-67: Add logic to this catch clause or eliminate it and rethrow the exception automatically.
src/api/resume/queries.ts
[warning] 30-30: Unexpected negated condition.
🔇 Additional comments (5)
src/components/resume/OtherResume.tsx (1)
23-34: LGTM!
useUserResumeListQuery훅을 통한 데이터 페칭과 로딩/에러 상태 처리가 적절하게 구현되었습니다. 사이드바 컴포넌트로서 첫 페이지만 사용하는 것도 합리적인 선택입니다.src/api/resume/mutations.ts (1)
7-22: LGTM! 타깃팅 무효화 적용이전 리뷰에서 제안된 대로
useMutation제네릭과mutationKey가 추가되었고,resumeKeys.all대신 세부적인 쿼리 키(detail,lists,bestList)를 타깃팅하여 무효화하도록 개선되었습니다.src/api/resume/queries.ts (1)
23-27: LGTM! 쿼리 키에 limit 포함이전 리뷰에서 제안된 대로
initialLimit과pageLimit이 쿼리 키에 포함되어 캐시 충돌 문제가 해결되었습니다.src/components/mypage/Resume.tsx (1)
14-14: LGTM! Props 타입 명시이전 리뷰에서 제안된 대로
userId에 명시적 타입이 추가되었습니다.참고: SonarCloud에서 props를
readonly로 표시하라는 경고가 있지만, 함수 컴포넌트에서 객체 구조분해 할당된 props는 이미 변경 불가능하므로 무시해도 됩니다.src/app/(protected)/resume/@resumeList.tsx (1)
116-124: 무한 스크롤 트리거 및 pages 플랫맵 로직은 적절해 보입니다
inView && hasNextPage && !isFetchingNextPage조건으로fetchNextPage를 호출하는 구조가 React Query v5useInfiniteQuery패턴에 잘 맞습니다.resumes를data?.pages.flatMap((page) => page.data.map(convertToResume)) ?? []로 구성하는 것도 API 응답 구조에 맞는 단순한 플랫맵 형태라 유지보수 측면에서 이해하기 쉽습니다.hasNextPage가 있을 때만 sentinel<div ref={ref} />를 렌더링하는 것도 불필요한 fetch 방지를 잘 처리하고 있습니다.현재 구조는 기능적으로 괜찮아 보이며, 별다른 수정 없이 가져가도 될 것 같습니다.
Also applies to: 160-168
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
♻️ Duplicate comments (2)
src/hooks/resume/useResumeLikeBookmark.ts (2)
21-21: bookmarkCount가 여전히 잘못된 값으로 초기화되고 있습니다.북마크 카운트를 좋아요 카운트(
initialLikeCount)로 초기화하면 즉시 잘못된 수치가 표시됩니다. 별도의 초기값을 사용하거나 서버에서 북마크 카운트를 제공하는 경우 해당 값을 사용해야 합니다.다음 diff를 적용하여 수정하세요:
const [likeCount, setLikeCount] = useState(initialLikeCount) -const [bookmarkCount, setBookmarkCount] = useState(initialLikeCount) +const [bookmarkCount, setBookmarkCount] = useState(0) // 서버에서 제공 시 해당 값으로 대체
78-78:onBookmarkUpdate호출 전 타입 체크가 필요합니다.
onLikeUpdate는typeof가드가 있지만(line 51),onBookmarkUpdate는 가드 없이 직접 호출됩니다. 일관성을 위해 동일한 패턴을 적용하세요.- onBookmarkUpdate(resume.id, newBookmarkCount) + if (typeof onBookmarkUpdate === 'function') { + onBookmarkUpdate(resume.id, newBookmarkCount) + }
🧹 Nitpick comments (1)
src/hooks/resume/useResumeLikeBookmark.ts (1)
27-27: likeList 콜백의 변수명이 혼란스럽습니다.Line 27에서
likeList.some()의 콜백 파라미터명이bookmark로 되어 있어 가독성이 떨어집니다.like로 변경하는 것이 명확합니다.setIsLike( likeList.some( - (bookmark: any) => String(bookmark.id) === String(resume.id), + (like: any) => String(like.id) === String(resume.id), ), )
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
src/components/mypage/ResumeFolder.tsx(2 hunks)src/components/resume/ResumeFolder.tsx(2 hunks)src/hooks/resume/useResumeLikeBookmark.ts(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- src/components/resume/ResumeFolder.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
src/components/mypage/ResumeFolder.tsx (2)
src/components/mypage/Resume.tsx (1)
Resume(14-179)src/hooks/resume/useResumeLikeBookmark.ts (1)
useResumeLikeBookmark(6-94)
src/hooks/resume/useResumeLikeBookmark.ts (4)
src/app/blog/_lib/useLike.ts (1)
useLike(3-51)src/app/blog/_lib/useBookmark.ts (1)
useBookmark(3-51)src/api/resume/mutations.ts (1)
useResumeBookmarkMutation(7-23)src/components/mypage/Bookmark.tsx (1)
resume(102-105)
🪛 GitHub Check: SonarCloud Code Analysis
src/hooks/resume/useResumeLikeBookmark.ts
[warning] 15-15: Remove this useless assignment to variable "postBookmark".
🔇 Additional comments (1)
src/components/mypage/ResumeFolder.tsx (1)
25-33: 훅 통합 리팩토링이 깔끔하게 적용되었습니다.좋아요/북마크 로직을
useResumeLikeBookmark훅으로 위임하여 컴포넌트의 책임을 명확히 분리했습니다. 다만, 이전 리뷰에서 지적된 문제들(bookmarkCount 초기화, onBookmarkUpdate 타입 가드)이 훅 내부로 이동했으므로 해당 파일에서 확인이 필요합니다.
| const { postLike } = useLike() | ||
| const { postBookmark } = useBookmark() | ||
| const bookmarkMutation = useResumeBookmarkMutation() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
좋아요와 북마크 API 사용 방식이 불일치합니다.
좋아요는 기존 postLike 함수를 사용하고(line 49), 북마크는 새로운 bookmarkMutation을 사용합니다(line 72). 일관성을 위해 두 기능 모두 동일한 방식(신규 mutation 훅 또는 기존 함수)을 사용해야 합니다.
권장 방안: 좋아요에도 useResumeLikeMutation 같은 mutation 훅을 도입하여 일관된 패턴을 유지하세요.
// 예시
const likeMutation = useResumeLikeMutation()
// ...
await likeMutation.mutateAsync({
contentId: Number(resume.id),
category: 'RESUME',
likeStatus: newIsLike,
})Also applies to: 49-49, 72-72
🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis
[warning] 15-15: Remove this useless assignment to variable "postBookmark".
🤖 Prompt for AI Agents
In src/hooks/resume/useResumeLikeBookmark.ts around lines 14-16 and occurrences
at lines 49 and 72, likes and bookmarks are used inconsistently (likes use
postLike while bookmarks use a mutation hook); replace the postLike usage with a
dedicated mutation hook (e.g., const likeMutation = useResumeLikeMutation()) and
call likeMutation.mutateAsync with the same payload shape used for bookmarks
(contentId: Number(resume.id), category: 'RESUME', likeStatus: newIsLike) so
both features use the same mutation-hook pattern and error handling.
| onBookmarkUpdate: (resumeId: string, newBookmarkCount: number) => void, | ||
| ) => { | ||
| const { postLike } = useLike() | ||
| const { postBookmark } = useBookmark() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
사용되지 않는 postBookmark 변수를 제거하세요.
useBookmark()에서 구조 분해한 postBookmark가 실제로 사용되지 않습니다. Line 72에서는 bookmarkMutation.mutateAsync를 사용하고 있어 중복입니다.
다음 diff를 적용하세요:
const { postLike } = useLike()
-const { postBookmark } = useBookmark()
const bookmarkMutation = useResumeBookmarkMutation()Based on learnings, SonarCloud 정적 분석 경고가 정확합니다.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { postBookmark } = useBookmark() | |
| const { postLike } = useLike() | |
| const bookmarkMutation = useResumeBookmarkMutation() |
🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis
[warning] 15-15: Remove this useless assignment to variable "postBookmark".
🤖 Prompt for AI Agents
In src/hooks/resume/useResumeLikeBookmark.ts around line 15, the destructured
variable `postBookmark` from `useBookmark()` is unused (duplicate of
bookmarkMutation.mutateAsync) and triggers SonarCloud warning; remove
`postBookmark` from the destructuring so only the actually used members are
extracted (or destructure nothing if none are used), and run a quick compile/TS
check to ensure no remaining references to `postBookmark`.
| setIsLike(!isLike) | ||
| setLikeCount(isLike ? likeCount : likeCount - 1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
에러 롤백 로직이 부정확합니다.
현재 롤백 로직은 이미 업데이트된 state 값을 참조하여 원래 값으로 복원하지 못할 수 있습니다. 낙관적 업데이트 전 원본 값을 저장한 후 에러 시 복원해야 합니다.
각 핸들러를 다음과 같이 수정하세요:
clickLike 수정:
const clickLike = async (event: React.MouseEvent) => {
event.preventDefault()
+ const originalIsLike = isLike
+ const originalLikeCount = likeCount
+
try {
const newIsLike = !isLike
const newLikeCount = newIsLike ? likeCount + 1 : likeCount - 1
setIsLike(newIsLike)
setLikeCount(newLikeCount)
await postLike(Number(resume.id), 'RESUME', newIsLike)
if (typeof onLikeUpdate === 'function') {
onLikeUpdate(resume.id, newLikeCount)
}
} catch (err) {
- setIsLike(!isLike)
- setLikeCount(isLike ? likeCount : likeCount - 1)
+ setIsLike(originalIsLike)
+ setLikeCount(originalLikeCount)
console.error(err)
}
}clickBookmark 수정:
const clickBookmark = async (event: React.MouseEvent) => {
event.preventDefault()
+ const originalIsBookmark = isBookmark
+ const originalBookmarkCount = bookmarkCount
+
try {
const newIsBookmark = !isBookmark
const newBookmarkCount = newIsBookmark
? bookmarkCount + 1
: bookmarkCount - 1
setIsBookmark(newIsBookmark)
setBookmarkCount(newBookmarkCount)
await bookmarkMutation.mutateAsync({
contentId: Number(resume.id),
category: 'RESUME',
bookmarkStatus: newIsBookmark,
})
onBookmarkUpdate(resume.id, newBookmarkCount)
} catch (err) {
- setIsBookmark(!isBookmark)
- setBookmarkCount(isBookmark ? bookmarkCount : bookmarkCount - 1)
+ setIsBookmark(originalIsBookmark)
+ setBookmarkCount(originalBookmarkCount)
console.error(err)
}
}Also applies to: 80-81
🤖 Prompt for AI Agents
In src/hooks/resume/useResumeLikeBookmark.ts around lines 55-56 (and similarly
lines 80-81), the optimistic rollback uses the possibly-updated state values and
can restore incorrect values; capture the prior state values before performing
the optimistic set (e.g., save prevIsLike and prevLikeCount before toggling, and
prevIsBookmark/prevBookmarkCount for bookmark), perform the optimistic update,
and in the catch/error branch restore those saved prev values to revert the UI
accurately; also ensure you only update counts based on the saved previous
boolean (increment/decrement from prevLikeCount or prevBookmarkCount) rather
than deriving from the post-toggle state.


요약
기본 fetch 라이브러리로 API 요청을 보내던 이력서 도메인의 함수들을
Tanstack-Query를 이용한 방식으로 변경했습니다.
/app에 api, hook 이 있엇어서 먼저 분리하며 진행했습니다. (삭제하진 않음)
작업 내용
참고 사항
관련 이슈
Summary by CodeRabbit
New Features
Refactor
Bug Fixes
✏️ Tip: You can customize this high-level summary in your review settings.